/*
 * Tranquil Java Integrated Development Environment
 *
 * The GNU General Public License Version 3
 *
 * Copyright (C) 2021 Autumn Lamonte
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * @author Autumn Lamonte [AutumnWalksTheLake@gmail.com] ⚧ Trans Liberation Now
 * @version 1
 */
package tjide.debugger;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import com.sun.jdi.AbsentInformationException;
import com.sun.jdi.Bootstrap;
import com.sun.jdi.Location;
import com.sun.jdi.ReferenceType;
import com.sun.jdi.ThreadReference;
import com.sun.jdi.VirtualMachine;
import com.sun.jdi.VirtualMachineManager;
import com.sun.jdi.VMDisconnectedException;
import com.sun.jdi.connect.AttachingConnector;
import com.sun.jdi.connect.Connector;
import com.sun.jdi.event.BreakpointEvent;
import com.sun.jdi.event.ClassPrepareEvent;
import com.sun.jdi.event.Event;
import com.sun.jdi.event.EventQueue;
import com.sun.jdi.event.EventSet;
import com.sun.jdi.event.StepEvent;
import com.sun.jdi.event.VMDeathEvent;
import com.sun.jdi.event.VMDisconnectEvent;
import com.sun.jdi.request.BreakpointRequest;
import com.sun.jdi.request.ClassPrepareRequest;
import com.sun.jdi.request.EventRequest;
import com.sun.jdi.request.EventRequestManager;
import com.sun.jdi.request.StepRequest;

import gjexer.TExceptionDialog;

import tjide.project.FileTarget;
import tjide.project.JavaTarget;
import tjide.project.Project;
import tjide.project.Target;
import tjide.ui.TranquilApplication;

/**
 * JavaDebugger uses the Java Platform Debugger Architecture to debug Java
 * applications.
 */
public class JavaDebugger implements Debugger, Runnable {

    // ------------------------------------------------------------------------
    // Variables --------------------------------------------------------------
    // ------------------------------------------------------------------------

    /**
     * The user interface application.  Note package private access.
     */
    TranquilApplication application;

    /**
     * The virtual machine being debugged.
     */
    private VirtualMachine virtualMachine;

    /**
     * The event manager for the VM being debugged.
     */
    private EventRequestManager eventRequestManager;

    /**
     * The event queue for the VM being debugged.
     */
    private EventQueue eventQueue;

    /**
     * The listeners to be notified during debugging.
     */
    private List<DebuggerListener> listeners;

    /**
     * The breakpoints to enable after the connection is established.
     */
    private List<Breakpoint> breakpoints;

    /**
     * The class names that have been requested by createClassPrepareRequest.
     */
    private List<String> classPrepareRequests = new ArrayList<String>();

    /**
     * The last thread seen by a breakpoint event.
     */
    private ThreadReference lastThread;

    /**
     * The go to cursor breakpoint.
     */
    private Breakpoint runToLocationBreakpoint;

    // ------------------------------------------------------------------------
    // Constructors -----------------------------------------------------------
    // ------------------------------------------------------------------------

    /**
     * Public constructor.
     *
     * @param application the UI application
     * @param host the host to connect to
     * @param port the port to connect to
     * @param listeners the listeners being notified of the debugging
     * @param breakpoints the breakpoints to enable
     * @param runToLocationTarget if set, run to a specific line
     * @param runToLocationLine if positive, the line to run to
     */
    public JavaDebugger(final TranquilApplication application,
        final String host, final int port,
        final List<DebuggerListener> listeners,
        final List<Breakpoint> breakpoints,
        final JavaTarget runToLocationTarget, final int runToLocationLine) {

        this.application = application;
        this.listeners = listeners;
        for (DebuggerListener listener: listeners) {
            listener.setDebugger(this);
        }

        // Connect to the VM.  If this fails virtualMachine,
        // eventRequestManager, and eventQueue will all be null.
        connectToVirtualMachine(host, port);

        // Set up for breakpoints.
        synchronized (breakpoints) {
            this.breakpoints = new ArrayList<Breakpoint>(breakpoints);
        }

        for (Breakpoint breakpoint: this.breakpoints) {

            /*
            System.err.println("Breakpoint to add: " +
                breakpoint.getTarget() +
                " line " + breakpoint.getLine());
            */

            addBreakpoint(breakpoint);
        }
        if (runToLocationLine > 0) {
            runToLocation(runToLocationTarget, runToLocationLine);
        }

        // Spin up the VM reader thread.
        (new Thread(this)).start();

        // Breakpoints are ready for setting, we are now ready to start the
        // VM.
        resume();
    }

    // ------------------------------------------------------------------------
    // Debugger ---------------------------------------------------------------
    // ------------------------------------------------------------------------

    /**
     * Continue execution.
     */
    public void resume() {
        if (virtualMachine != null) {
            virtualMachine.resume();
        }
    }

    /**
     * Terminate the program.
     */
    public void reset() {
        if (virtualMachine != null) {
            try {
                virtualMachine.exit(-1);
            } catch (VMDisconnectedException e) {
                // SQUASH
            }
            virtualMachine = null;
        }
    }

    /**
     * Continue one more step, including going into a new stack frame.
     */
    public void traceInto() {
        if (virtualMachine == null) {
            return;
        }
        StepRequest request = eventRequestManager.createStepRequest(lastThread,
            StepRequest.STEP_LINE,
            StepRequest.STEP_INTO);
        request.addCountFilter(1);
        request.enable();
        virtualMachine.resume();
    }

    /**
     * Continue one more step, skipping over a new stack frame.
     */
    public void stepOver() {
        if (virtualMachine == null) {
            return;
        }
        StepRequest request = eventRequestManager.createStepRequest(lastThread,
            StepRequest.STEP_LINE,
            StepRequest.STEP_OVER);
        request.addCountFilter(1);
        request.enable();
        virtualMachine.resume();
    }

    /**
     * Go to a specific file location.
     *
     * @param target the target
     * @param line the line number
     */
    public void runToLocation(final FileTarget target, final int line) {
        if (virtualMachine == null) {
            return;
        }

        runToLocationBreakpoint = new Breakpoint(target, line);
        synchronized (breakpoints) {
            breakpoints.add(runToLocationBreakpoint);
            addBreakpoint(runToLocationBreakpoint);
        }
    }

    /**
     * Add a breakpoint.
     *
     * @param target the target
     * @param line the line number
     */
    public void addBreakpoint(final FileTarget target, final int line) {
        if (virtualMachine == null) {
            return;
        }

        Breakpoint breakpoint = new Breakpoint(target, line);
        synchronized (breakpoints) {
            breakpoints.add(breakpoint);
            addBreakpoint(breakpoint);
        }
    }

    /**
     * Remove a breakpoint.
     *
     * @param target the target
     * @param line the line number
     */
    public void removeBreakpoint(final FileTarget target, final int line) {
        Breakpoint removeBreakpoint = null;
        synchronized (breakpoints) {
            for (Breakpoint breakpoint: breakpoints) {
                if (breakpoint.getTarget().equals(target)
                    && (breakpoint.getLine() == line)
                ) {
                    if (breakpoint.breakpointRequest != null) {
                        breakpoint.breakpointRequest.disable();
                    }
                    removeBreakpoint = breakpoint;
                    break;
                }
            }
            if (removeBreakpoint != null) {
                breakpoints.remove(removeBreakpoint);
            }
        }
    }

    // ------------------------------------------------------------------------
    // JavaDebugger -----------------------------------------------------------
    // ------------------------------------------------------------------------

    /**
     * Connect to a virtual machine that is (or will be very soon) listening
     * for a JPDA connection.
     *
     * @param host the host to connect to
     * @param port the port to connect to
     */
    private void connectToVirtualMachine(final String host, final int port) {

        VirtualMachineManager vmManager = Bootstrap.virtualMachineManager();
        for (AttachingConnector connector: vmManager.attachingConnectors()) {
            try {
                Map<String, Connector.Argument> args = connector.defaultArguments();
                if (args.get("hostname") == null) {
                    continue;
                }
                if (args.get("port") == null) {
                    continue;
                }
                if (args.get("timeout") == null) {
                    continue;
                }
                args.get("hostname").setValue(host);
                ((Connector.IntegerArgument)args.get("port")).setValue(port);
                ((Connector.IntegerArgument)args.get("timeout")).setValue(250);
                // System.err.println(args);

                // Try to connect to the VM.  If connection is refused, keep
                // trying several times.
                for (int i = 0; ; i++) {
                    try {
                        virtualMachine = connector.attach(args);
                        break;
                    } catch (java.net.ConnectException e) {
                        if (i == 5) {
                            throw e;
                        }
                        Thread.sleep(100);
                    }
                }
                // System.err.println("Connected");

                eventRequestManager = virtualMachine.eventRequestManager();
                eventQueue = virtualMachine.eventQueue();
            } catch (final Exception e) {
                application.invokeLater(new Runnable() {
                    public void run() {
                        new TExceptionDialog(application, e);
                    }
                });
            }
        }
    }

    /**
     * Add a breakpoint.
     *
     * @param breakpoint the breakpoint
     */
    private void addBreakpoint(final Breakpoint breakpoint) {
        if (virtualMachine == null) {
            return;
        }

        if (!(breakpoint.getTarget() instanceof JavaTarget)) {
            return;
        }
        JavaTarget target = (JavaTarget) breakpoint.getTarget();
        String className = target.getTargetClassName(application.getProject());

        // If the class is already loaded, we just enable a breakpoint.
        boolean found = false;
        for (ReferenceType classRef: virtualMachine.classesByName(className)) {
            setBreakpoint(classRef, breakpoint.getLine());
            synchronized (breakpoints) {
                breakpoints.remove(breakpoint);
            }
            found = true;
        }
        if (found == true) {
            return;
        }

        // The class isn't loaded yet.  Create a ClassPrepareRequest and when
        // that comes in enable the breakpoint at that time.
        if (!classPrepareRequests.contains(className)) {
            classPrepareRequests.add(className);

            ClassPrepareRequest request;
            request = eventRequestManager.createClassPrepareRequest();
            request.addClassFilter(className);
            request.setSuspendPolicy(EventRequest.SUSPEND_ALL);
            request.enable();
        }
    }

    /**
     * Set a breakpoint.
     *
     * @param reference the reference to the class
     * @param line the line number
     */
    private void setBreakpoint(final ReferenceType reference, final int line) {
        assert (virtualMachine != null);

        // System.err.println("Add breakpoint: " + reference + " line " + line);

        // Find the location for this breakpoint.
        try {
            for (Location location: reference.locationsOfLine(line)) {
                BreakpointRequest breakpoint;
                breakpoint = eventRequestManager.createBreakpointRequest(location);
                breakpoint.setSuspendPolicy(EventRequest.SUSPEND_ALL);
                breakpoint.enable();
            }
        } catch (final AbsentInformationException e) {
            application.invokeLater(new Runnable() {
                public void run() {
                    new TExceptionDialog(application, e);
                }
            });
        }
    }

    /**
     * Handle a breakpoint event.
     *
     * @param event the breakpoint event
     */
    private void onBreakpoint(final BreakpointEvent event) {
        // System.err.println("breakpoint: " + event);

        Location location = event.location();
        lastThread = event.thread();
        try {
            for (DebuggerListener listener: listeners) {
                listener.setDebugScope(new JavaScope(this, event));
            }
            if (runToLocationBreakpoint != null) {
                if (location.sourceName().equals(runToLocationBreakpoint.getTarget().getName())
                    && (location.lineNumber() == runToLocationBreakpoint.getLine())
                ) {
                    // This was the one-time "run to cursor" breakpoint,
                    // disable it.
                    event.request().disable();
                    runToLocationBreakpoint = null;
                }
            }

            for (Breakpoint breakpoint: getJavaBreakpoints()) {
                if (location.sourceName().equals(breakpoint.getTarget().getName())
                    && (location.lineNumber() == breakpoint.getLine())
                ) {
                    // Increment pass count.
                    breakpoint.passCount++;
                }
            }
        } catch (final AbsentInformationException e) {
            application.invokeLater(new Runnable() {
                public void run() {
                    new TExceptionDialog(application, e);
                }
            });
        }
    }

    /**
     * Get all of the breakpoints for JavaTargets from the project.
     *
     * @return the breakpoints
     */
    private List<Breakpoint> getJavaBreakpoints() {
        List<Breakpoint> breakpoints = new ArrayList<Breakpoint>();

        Project project = application.getProject();
        if (project == null) {
            return breakpoints;
        }

        for (Target target: project.getTargets()) {
            if (target instanceof JavaTarget) {
                breakpoints.addAll(((JavaTarget) target).getBreakpoints());
            }
        }
        return breakpoints;
    }

    /**
     * Handle a step event.
     *
     * @param event the step event
     */
    private void onStep(final StepEvent event) {
        // System.err.println("step: " + event);
        Location location = event.location();
        for (DebuggerListener listener: listeners) {
            listener.setDebugScope(new JavaScope(this, event));
        }
        eventRequestManager.deleteEventRequest(event.request());
    }

    /**
     * Handle a class prepare event.
     *
     * @param event the class prepare event
     */
    private void onClassPrepare(final ClassPrepareEvent event) {
        // System.err.println("class prepare: " + event.referenceType());
        String className = event.referenceType().name();

        List<Breakpoint> breakpointsToRemove = new ArrayList<Breakpoint>();
        synchronized (breakpoints) {
            for (Breakpoint breakpoint: breakpoints) {
                FileTarget target = breakpoint.getTarget();
                if (!(target instanceof JavaTarget)) {
                    continue;
                }
                String targetName = ((JavaTarget) target).getTargetClassName(application.getProject());
                if (targetName.equals(className)) {
                    setBreakpoint(event.referenceType(), breakpoint.getLine());
                    breakpointsToRemove.add(breakpoint);
                }
            }
            breakpoints.removeAll(breakpointsToRemove);
        }
    }

    /**
     * Handle a VM death event.
     *
     * @param event the VM death event
     */
    private void onDeath(final VMDeathEvent event) {
        if (virtualMachine != null) {
            virtualMachine = null;
            for (DebuggerListener listener: listeners) {
                listener.setDebugProgramExited(-1);
            }
        }
    }

    /**
     * Handle a VM disconnect event.
     *
     * @param event the VM disconnect event
     */
    private void onDisconnect(final VMDisconnectEvent event) {
        if (virtualMachine != null) {
            virtualMachine = null;
            for (DebuggerListener listener: listeners) {
                listener.setDebugProgramExited(-1);
            }
        }
    }

    // ------------------------------------------------------------------------
    // Runnable ---------------------------------------------------------------
    // ------------------------------------------------------------------------

    /**
     * Process events from the VM event queue.
     */
    public void run() {
        try {
            boolean done = false;
            while (!done) {
                EventSet eventSet = null;
                boolean doResume = false;
                try {
                    eventSet = eventQueue.remove();
                    // System.err.println("eventSet: " + eventSet);
                } catch (InterruptedException e) {
                    // SQUASH
                    continue;
                }
                for (Event event: eventSet) {
                    // System.err.println("Event: " + event);

                    if (event instanceof VMDeathEvent) {
                        onDeath((VMDeathEvent) event);
                        done = true;
                    } else if (event instanceof VMDisconnectEvent) {
                        onDisconnect((VMDisconnectEvent) event);
                        done = true;
                    } else if (event instanceof StepEvent) {
                        onStep((StepEvent) event);
                    } else if (event instanceof BreakpointEvent) {
                        onBreakpoint((BreakpointEvent) event);
                    } else if (event instanceof ClassPrepareEvent) {
                        onClassPrepare((ClassPrepareEvent) event);
                        doResume = true;
                    }
                }
                if (doResume) {
                    resume();
                }
            }
        } catch (VMDisconnectedException e) {
            // Very fast-running machines can throw this in event.toString().
            // Just exit cleanly.
            virtualMachine = null;
        } catch (final Throwable t) {
            application.invokeLater(new Runnable() {
                public void run() {
                    new TExceptionDialog(application, t);
                }
            });
        }
    }

}
